Mise à jour le 13/11/2021
Immutable vs Mutable

Immutable vs Mutable


1. Email de prérequis

Voici l'email de prérequis.
https://marc.info/?l=php-internals&m=160935644205607&w=2

List: php-internals
Subject: =?UTF-8?Q?Re:_[PHP-DEV]_Analysis_of_property_visibility,_immutability,_a?= =?UTF-8?Q?nd_cloning_prop
From: "Larry Garfield" <larry () garfieldtech ! com>
Date: 2020-12-30 19:26:25
Message-ID: b3fba6e2-8547-4de6-a09f-bd980b24a97a () www ! fastmail ! com
[Download RAW message or body]


> > That's a good summary of why immutability and with-er methods (or some
> > equivalent) are more ergonomic.
> >
> > Another point to remember: Because of PHP's copy-on-write behavior, full on
> > immutability doesn't actually waste that much memory. It does use up some,
> > but far less than you think. (Again, based on the tests MWOP ran for PSR-7
> > a ways back.)
>
> I thought copy-on-write was only for arrays, not objects?
>
> Olle

Copy on write applies to all values; the caveat is that with objects, the value being \
copied is the handle that points to an object in memory, rather than the object \
itself. That means passing an object by reference can do some seriously unexpected \
things, which is why you basically never do so.

The point here is that if you have an object with 15 internal properties, it's memory \
usage is 15 zvals plus one zval for the object, plus one zval for the variable that \
points to it. (I'm over-simplifying here. A lot.) If you pass it to a function, \
only the one zval for the handle is duplicated, which is the same as for an integer.

If you clone the object, you don't duplicate 15+1 zvals. You duplicate just the one \
zval for the object itself, which reuses the existing 15 internal property entries. \
If in the new object you then update just the third one, PHP then duplicates just \
that one internal zval and modifies the new one. So you still are using only 18 \
zvals, not 36 zvals. (Engine people: Yes, I am *very* over-simplifying. I know.)

Basically, what in most languages would require manually implementing "immutable data \
structures" we get for free in PHP, which is seriously sweet.

The net result is that a with-er chain like this:

$foo2 = $foo->withBar('x')->withBaz('y')->withBeep('z');

is way, way less expensive than it looks, both on memory and CPU. It is more \
expensive than setters, but not by much.

That's why I don't think the distinction between unique and immutable mentioned \
up-thread is that big of a deal in PHP, specifically. Yes, they're different things, \
but the cost of them is not all that different because of CoW, so considering them \
separately is not as important as it would be in a language that doesn't \
automatically do CoW in the background for us.

(Whoever in the 90s decided to bake CoW into the engine, thank you. It's an \
incredibly nice foundational feature.)

--Larry Garfield

--
PHP Internals - PHP Runtime Development Mailing List
To unsubscribe, visit: https://www.php.net/unsub.php

2. Ce que j'en comprends

J'en comprends que si on construit des clones d'instances à chaque withXxx plutôt que de faire un clone suivi de setter, même si selon l'auteur, cela consomme un peu plus de mémoire/CPU mais c'est négligeable comparé à l'intéret que c'est de travailler avec des immutables.

En bref, comparer ceci :

<?php
$foo = new Foo();
$foo2 = $foo->withBar('x')->withBaz('y')->withBeep('z');

Avec ceci :

<?php
$foo = new Foo();
$foo2 = clone $foo;
$foo2 = $foo2->setBar('x')->setBaz('y')->setBeep('z');

Cette deuxième version, pour un même résultat, serait plus gourmande en mémoire et en CPU que la première version.

3. On teste !

3.1 Classe Foo.

Bon, ce qui est bien, c'est que l'on peut définir la même classe Foo pour les deux façons de faire.

<?php
class Foo
{
    protected string $bar = 'a';
    protected string $baz = 'b';
    protected string $beep = 'c';

    public function __construct()
    {
        return $this;
    }

    public function setBar(string $bar)
    {
        $this->bar = $bar;

        return $this;
    }

    public function setBaz(string $baz)
    {
        $this->baz = $baz;

        return $this;
    }

    public function setBeep(string $beep)
    {
        $this->beep = $beep;

        return $this;
    }

    public function withBar(string $bar)
    {
        $clonedFoo = clone $this;
        $clonedFoo->bar = $bar;

        return $clonedFoo;
    }
    public function withBaz(string $baz)
    {
        $clonedFoo = clone $this;
        $clonedFoo->baz = $baz;

        return $clonedFoo;
    }
    public function withBeep(string $beep)
    {
        $clonedFoo = clone $this;
        $clonedFoo->beep = $beep;

        return $clonedFoo;
    }
}


3.2 Test de l'usage mémoire.

Pour calculer l'usage qu'est faite de la mémoire, on peut utiliser la fonction PHP memory_get_usage à plusieurs endroits du code et faire la différence.

$m1 =  memory_get_usage();
$foo = new Foo();
$foo2 = $foo->withBar('x')->withBaz('y')->withBeep('z');
$m2 = memory_get_usage();
echo ($m2 - $m1).PHP_EOL;


$m1 =  memory_get_usage();
$foo3 = new Foo();
$foo4 = clone $foo3;
$foo4 = $foo4->setBar('x')->setBaz('y')->setBeep('z');
$m2 = memory_get_usage();
echo ($m2 - $m1).PHP_EOL;


Le résultat que j'ai obtenu est celui-ci :

192
192

Autrement dit : aucune différence. Ce qui amène plusieurs hypothèses :
- soit je n'ai pas compris ce dont il était question ;
- soit j'ai mal implémenté le truc ;
- soit memory_get_usage ne fait pas ce que je souhaite ;
- ou soit il n'y a effectivement aucune différence de mémoire, en tout cas au final, peut-être qu'à chaque étape intermédiaire, si ;

En testant la dernière hypothèse, et donc en regardant la mémoire utilisée maximale sur chaque cas entre chaque étape, j'obtiens :
Cas 1 :

<?php
$max1 = 0;
$max1 = max($max1, memory_get_usage());
$foo = new Foo();
$max1 = max($max1, memory_get_usage());
$foo2 = $foo->withBar('x');
$max1 = max($max1, memory_get_usage());
$foo2 = $foo2->withBaz('y');
$max1 = max($max1, memory_get_usage());
$foo2 = $foo2->withBeep('z');
$max1 = max($max1, memory_get_usage());
echo ($max1).PHP_EOL;

Cas 2 :

$max1 = 0;
$max1 = max($max1, memory_get_usage());
$foo3 = new Foo();
$max1 = max($max1, memory_get_usage());
$foo4 = clone $foo3;
$max1 = max($max1, memory_get_usage());
$foo4 = $foo4->setBar('x');
$max1 = max($max1, memory_get_usage());
$foo4 = $foo4->setBaz('y');
$max1 = max($max1, memory_get_usage());
$foo4 = $foo4->setBeep('z');
$max1 = max($max1, memory_get_usage());
echo ($max1).PHP_EOL;

Cas 1 : 406464
Cas 2 : 406488

Soit une différence de 14 octets, le deuxième cas étant donc à un moment plus gourmand en mémoire que le premier cas.

3.3 Autres benchmarks

$foo = new Foo();
$foo2 = $foo->withBar('x')->withBaz('y')->withBeep('z');

-> 404680

$foo = new Foo();
$foo2 = $foo->withBar('x');
$foo2 = $foo2->withBaz('y');
$foo2 = $foo2->withBeep('z');

-> 404808

$foo3 = new Foo();
$foo4 = clone $foo3;
$foo4 = $foo4->setBar('x');
$foo4 = $foo4->setBaz('y');
$foo4 = $foo4->setBeep('z');

-> 404832

$foo3 = new Foo();
$foo4 = clone $foo3;
$foo4 = $foo4->setBar('x')->setBaz('y')->setBeep('z');

-> 404832

Analyse : La version immutable où les withXxx sont chainés consomme moins de mémoire que les autres versions dont celle mutable avec les setXxx chainés, donc ce code-ci n'est pas une preuve de ce que dit l'auteur du mail, ce serait même l'inverse.

4. Conclusion

Bien que j'ai n'a pas réussi à montrer que la mémoire utilisée est plus grande lorsqu'on manipule des objets immutables, et c'est même l'inverse dans mon cas, la version avec des withXxx parait être très bien gérée par PHP et donc on peut imaginer que coder avec des objets immutables serait très souhaité.